Skip to content

Conversation

m-bert
Copy link
Contributor

@m-bert m-bert commented Aug 26, 2025

Important

Supersede #3664.

I've decided to create a separate PR since it was easier to start working on it directly than waiting for #3682 to be merged.

Description

This PR introduces hooks to set relations between handlers.

API

New API replaces the old one as follows:

  • Gesture.Race(g1, g2) $\rightarrow$ useRace(g1, g2)
  • Gesture.Exclusive(g1, g2) $\rightarrow$ useExclusive(g1, g2)
  • Gesture.Simultaneous(g1, g2) $\rightarrow$ useSimultaneous(g1, g2)

Algorithm for populating relations

Handling external relations

In order to properly handle gesture relations, we need to pass 3 arrays to the native side:

  • waitFor - responsible for handling Exclusive and requireExternalGestureToFail relations
  • simultaneousHandlers - responsible for Simultaneous and simultaneousWithExternalGesture relations
  • blocksHandlers - responsible for blocksExternalGesture relation

At first, these arrays are filled with external relations in useGesture hook. Then we use DFS algorithm to add remaining relations, added with relation hooks. Since Race doesn't really change anything when it comes to gesture interactions, we can ignore it in our algorithm.

DFS overview

We use DFS because gesture relations form tree structure.

The algorithm works as follows:
  • Initialize two arrays: waitFor and simultaneousHandlers. If root node is SimultaneousGesture, we also add its handler tags into simultaneousHandlers array. This ensures that the algorithm works even if we have only Simultaneous as the root node (e.g. useSimultaneous(g1, g1))
  • Traverse the gesture tree:
    • If we are not in the ComposedGesture, it means that we reached leaf node. In that case we populate waitFor and simultanoursHandlers arrays into node arrays and then update relations on the native side
    • If we are in the ComposedGesture, then for each child:
      • If the child is not ComposedGesture:
        • We call traverseGestureRelations to reach stop condition and configure relations on the native side
        • If current node is Exclusive, then we add child tag to waitFor array
      • If the child is ComposedGesture:
        • On the way down:
          • Going from non-simultaneous gesture to simultaneous gesture we add all child tags into global simulatneousHandlers array
          • If we go from simultaneous to non-simultaneous gesture, we remove child tags instead of adding.
        • We store length of waitFor to reset it later.
        • We call traverseGestureRelations
        • On the way back:
          • if we go from simultaneous (child) to non-simultaneous (node) gesture, we remove node tags from simultaneousHandlers
          • Going from non-simultaneous (child) to simultaneous (node) gesture we add node tags instead of removing
          • Returning to Exclusive gesture means that we want to add all children tags into waitFor
          • If we return from Exclusive child to non-exclusive node, we want to reset waitFor to previous state, using length variable.

Example

Below you can see example of the algorithm.

We use the following notation:
  • Handlers and composition:
    • E - Exclusive
    • S - Simultaneous
    • P - Pan
    • T - Tap
  • Relation arrays:
    • SH - simultaneousHandlers
    • WF - waitFor
  • Operators:
    • += - adding tags
    • -= - removing tags

Note: vertex label in relation arrays expands to all tags in the composed gesture.

graph TB
    E1["E₁"] --> |SH += S₁| S1
    E1["E₁"] --> |SH += S₂| S2

    S1["S₁"] --> |SH -= S₁ <br/> WF += S₁| E1
    S1 --> |SH -= E₂| E2
    S1 --> P3
    E2["E₂"] --> |SH += E₂ <br/> WF -= E₂| S1
    P3["P₃ <br/> SH: #91;T₁, T₂, P₃#93;<br/>WF: #91;#93;"] --> S1

    E2 --> T1
    E2 --> T2
    T1["T₁ <br/> SH: #91;P₃#93;<br/>WF: #91;#93;"] --> |WF += T₁| E2
    T2["T₂ <br/> SH: #91;P₃#93;<br/>WF: #91;T₁#93;"] --> |WF += T₂| E2

    S2["S₂"] -->|SH -= S₂| E1
    S2 --> P4
    S2 --> P5
    P4["P₄ <br/> SH: #91;P₄, P₅#93;<br/>WF: #91;T₁, T₂, P₃#93;"] --> S2
    P5["P₅ <br/> SH: #91;P₄, P₅#93;<br/>WF: #91;T₁, T₂, P₃#93;"] --> S2

    style DFS fill-opacity:0,stroke-opacity:0,stroke-width:0px
Loading

Limitations

Currently the following setup doesn't work on android:

const composedGesture = useExclusive(tap1, useRace(pan1, pan2));

I've managed to find out what is the difference between this and using only useRace.

Warning

This problem seems to be present also on main, so I think it will be better to solve it in the follow-up PR.

For now, external relation props do not support composed gestures. Let me know if this should be done in this PR, or in a follow-up.

Test plan

Same detector interactions

Verified that the following relations work:

Android
  • Simultaneous
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • Exclusive
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • Race
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • Simple composition
    • Exclusive + Simultaneous
iOS
  • Simultaneous
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • Exclusive
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • Race
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • Simple composition
    • Exclusive + Simultaneous
Base code used for testing:
import * as React from 'react';
import { Animated, Button, useAnimatedValue } from 'react-native';
import {
  GestureHandlerRootView,
  NativeDetector,
  useSimultaneous,
  useGesture,
  useExclusive,
  useRace,
} from 'react-native-gesture-handler';

export default function App() {
  const [visible, setVisible] = React.useState(true);

  const value = useAnimatedValue(0);
  const event = Animated.event(
    [{ nativeEvent: { handlerData: { translationX: value } } }],
    {
      useNativeDriver: true,
    }
  );

  const tap1 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 1');
    },
    numberOfTaps: 1,
    disableReanimated: true,
  });

  const tap2 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 2');
    },
    numberOfTaps: 2,
    disableReanimated: true,
  });

  const pan1 = useGesture('PanGestureHandler', {
    // onUpdate: event,
    onUpdate: (e) => {
      // 'worklet';
      console.log('Pan 1');
    },
    disableReanimated: true,
  });

  const pan2 = useGesture('PanGestureHandler', {
    onUpdate: (e) => {
      // 'worklet';
      console.log('Pan 2');
    },
    disableReanimated: true,
  });

  const composedGesture = useSimultaneous(pan1, pan2);

  // const composedGesture = useExclusive(tap2, tap1);
  // const composedGesture = useExclusive(pan2, pan1); // For Animtaed.Event
  // const composedGesture = useExclusive(pan1, pan2); // For Animtaed.Event

  // const composedGesture = useRace(pan1, pan2);
  // const composedGesture = useRace(pan2, pan1);

  // const composedGesture = useExclusive(tap1, useSimultaneous(pan1, pan2));

  return (
    <GestureHandlerRootView
      style={{ flex: 1, backgroundColor: 'white', paddingTop: 8 }}>
      <Button
        title="Toggle visibility"
        onPress={() => {
          setVisible(!visible);
        }}
      />

      {visible && (
        <NativeDetector gesture={composedGesture}>
          <Animated.View
            style={[
              {
                width: 150,
                height: 150,
                backgroundColor: 'blue',
                opacity: 0.5,
                borderWidth: 10,
                borderColor: 'green',
                marginTop: 20,
                marginLeft: 40,
              },
              { transform: [{ translateX: value }] },
            ]}
          />
        </NativeDetector>
      )}
    </GestureHandlerRootView>
  );
}

Cross detector interactions

Verified that the following relations work:

Android
  • simultaneousWithExternalGesture
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • requireExternalGestureToFail
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • blocksExternalGesture
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
iOS
  • simultaneousWithExternalGesture
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • requireExternalGestureToFail
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
  • blocksExternalGesture
    • Only JS
    • Only Reanimated
    • JS + Reanimated
    • JS + Animated
Base code used for testing:
import * as React from 'react';
import { Animated, Button, useAnimatedValue } from 'react-native';
import {
  GestureHandlerRootView,
  NativeDetector,
  useSimultaneous,
  useGesture,
  useExclusive,
  useRace,
} from 'react-native-gesture-handler';

export default function App() {
  const [visible, setVisible] = React.useState(true);

  const value = useAnimatedValue(0);
  const event = Animated.event(
    [{ nativeEvent: { handlerData: { translationX: value } } }],
    {
      useNativeDriver: true,
    }
  );

  const tap1 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 1');
    },
    numberOfTaps: 1,
    disableReanimated: true,
  });

  const tap2 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 2');
    },
    numberOfTaps: 2,
    disableReanimated: true,
    blocksExternalGesture: tap1,
  });

  // const tap1 = useGesture('TapGestureHandler', {
  //   onEnd: () => {
  //     'worklet';
  //     console.log('Tap 1');
  //   },
  //   numberOfTaps: 1,
  //   // disableReanimated: true,
  //   requireExternalGestureToFail: tap2,
  // });

  const pan1 = useGesture('PanGestureHandler', {
    // onUpdate: event,
    onUpdate: (e) => {
      'worklet';
      console.log('Pan 1');
    },
    // disableReanimated: true,
  });

  const pan2 = useGesture('PanGestureHandler', {
    onUpdate: (e) => {
      'worklet';
      console.log('Pan 2');
    },
    simultaneousWithExternalGesture: pan1,
    // requireExternalGestureToFail: pan1,
    // blocksExternalGesture: pan1,
    // disableReanimated: true,
  });

  // const composedGesture = useSimultaneous(pan1, pan2);

  // const composedGesture = useExclusive(tap2, tap1);
  // const composedGesture = useExclusive(pan2, pan1); // For Animated.Event
  // const composedGesture = useExclusive(pan1, pan2); // For Animated.Event

  // const composedGesture = useRace(pan1, pan2);
  // const composedGesture = useRace(pan2, pan1);

  // const composedGesture = useExclusive(tap1, useSimultaneous(pan1, pan2));

  return (
    <GestureHandlerRootView
      style={{ flex: 1, backgroundColor: 'white', paddingTop: 8 }}>
      <Button
        title="Toggle visibility"
        onPress={() => {
          setVisible(!visible);
        }}
      />

      {visible && (
        <NativeDetector gesture={pan1}>
          <Animated.View
            style={[
              {
                width: 150,
                height: 150,
                backgroundColor: 'blue',
                opacity: 0.5,
                borderWidth: 10,
                borderColor: 'green',
                marginTop: 20,
                marginLeft: 40,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-around',
              },
              { transform: [{ translateX: value }] },
            ]}>
            <NativeDetector gesture={pan2}>
              <Animated.View
                style={{ width: 100, height: 100, backgroundColor: 'green' }}
              />
            </NativeDetector>
          </Animated.View>
        </NativeDetector>
      )}
    </GestureHandlerRootView>
  );
}

Base automatically changed from @mbert/extract-reanimated-handlers to next September 5, 2025 08:09
export type ComposedGestureType = ValueOf<typeof ComposedGestureType>;

// TODO: Find better name
export const HandlerType = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this name so if you have any idea how to name it let me know 😅

@m-bert m-bert marked this pull request as ready for review September 5, 2025 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant